#!/usr/bin/python3
# -*- coding: utf-8; tab-width: 4; indent-tabs-mode: t -*-

import os
import sys
import glob
import time
import signal
import shutil
import subprocess
import mirrors.plugin


class Main:

    def __init__(self):
        self.url = "https://fedorapeople.org/groups"

        self.id = mirrors.plugin.params["id"]
        self.cfg = mirrors.plugin.params["config"]
        self.dataDir = mirrors.plugin.params["storage-file"]["data-directory"]

        self.tmpCfgFile = os.path.join(self.dataDir, ".cfg")
        self.tmpMntDir = os.path.join(self.dataDir, ".mnt")
        self.tmpCacheDir = os.path.join(self.dataDir, ".cache")

        self.includeList = [
            "virt/***",
        ]
        self.excludeList = [
            "virt/virt",                             # this is a bad recursive symlink
            "virt/virtio-win/deprecated-isos/**",    # there're some bad symlinks in this directory
        ]

    def run(self):
        try:
            with HttpdirfsMount(self.tmpCacheDir, self.tmpCfgFile, self.url, self.tmpMntDir):
                # update once a week, mainly for filtering files according to self.includeList and self.excludeList
                while True:
                    Util.symlinkTo(self.tmpMntDir, self.dataDir,
                                   maxDepth=1024,       # to prevent server side recursive symlink
                                   srcIncludeWildcardList=self.includeList, srcExcludeWildcardList=self.excludeList,
                                   dstExcludeFullfnList=[self.tmpCacheDir, self.tmpCfgFile, self.tmpMntDir])
                    time.sleep(7 * 24 * 3600)
        finally:
            Util.removeDirContentExclude(self.dataDir, [self.tmpCacheDir])


class Util:

    @staticmethod
    def pathAddSlash(path):
        if path == "/":
            return path
        else:
            return path + "/"

    @staticmethod
    def forceDelete(path):
        if os.path.islink(path):
            os.remove(path)
        elif os.path.isfile(path):
            os.remove(path)
        elif os.path.isdir(path):
            shutil.rmtree(path)
        elif os.path.lexists(path):
            os.remove(path)             # other type of file, such as device node
        else:
            pass                        # path does not exist, do nothing

    @classmethod
    def forceMakeDir(cls, path):
        if not os.path.exists(path):
            os.mkdir(path)
        elif os.path.islink(path) or not os.path.isdir(path):
            cls.forceDelete(path)
            os.mkdir(path)
        else:
            pass

    @classmethod
    def forceMakeEmptyDir(cls, path):
        cls.forceDelete(path)
        os.mkdir(path)

    @classmethod
    def forceSymlink(cls, srcPath, dstPath):
        if not (os.path.islink(dstPath) and os.readlink(dstPath) == srcPath):
            cls.forceDelete(dstPath)
            os.symlink(srcPath, dstPath)

    @classmethod
    def removeDirContentExclude(cls, dirPath, excludeList):
        for fn in os.listdir(dirPath):
            if fn not in excludeList:
                cls.forceDelete(os.path.join(dirPath, fn))

    @classmethod
    def matchWildcard(cls, name, wildcard):
        if wildcard.endswith("/***"):
            wildcard = os.path.dirname(wildcard)
            if name == wildcard or name.startswith(cls.pathAddSlash(wildcard)):
                return True
        elif wildcard.endswith("/**"):
            wildcard = os.path.dirname(wildcard)
            if name.startswith(cls.pathAddSlash(wildcard)):
                return True
        else:
            if name == wildcard:
                return True
        return False

    @classmethod
    def iglob(cls, rootDir, curDir, curDepth, maxDepth=None, includeList=None, excludeList=None):
        assert maxDepth is None or curDepth <= maxDepth

        if curDir == "":
            fullDir = rootDir
        else:
            fullDir = os.path.join(rootDir, curDir)

        for fn in os.listdir(fullDir):
            fullfn = os.path.join(fullDir, fn)
            relfn = os.path.join(curDir, fn)

            # filter
            if includeList is not None:
                # for inclusion, also matches ancestor directories
                bMatch = False
                for x in includeList:
                    if x.startswith(cls.pathAddSlash(relfn)) or cls.matchWildcard(relfn, x):
                        bMatch = True
                if not bMatch:
                    continue
            if excludeList is not None:
                if any([cls.matchWildcard(relfn, x) for x in excludeList]):
                    continue

            assert not os.path.islink(fullfn)
            dirOrFile = os.path.isdir(fullfn)

            # return value
            yield (fullfn, relfn, dirOrFile, curDepth)

            # recursing
            if dirOrFile:
                for x in cls.iglob(rootDir, relfn, curDepth + 1, maxDepth, includeList, excludeList):
                    yield x

    @classmethod
    def symlinkTo(cls, srcDir, dstDir, maxDepth=None, srcIncludeWildcardList=None, srcExcludeWildcardList=None, dstExcludeFullfnList=None):
        # symlink new
        fullfnSet = set()
        for fullPath, relPath, dirOrFile, depth in cls.iglob(srcDir, "", 1, maxDepth=maxDepth, includeList=srcIncludeWildcardList, excludeList=srcExcludeWildcardList):
            fullfn = os.path.join(dstDir, relPath)
            if dstExcludeFullfnList is not None:
                assert fullfn not in dstExcludeFullfnList
            if dirOrFile:
                cls.forceMakeDir(fullfn)
            else:
                cls.forceSymlink(os.path.relpath(fullPath, dstDir), fullfn)
            fullfnSet.add(fullfn)

        # delete old
        for fn in os.listdir(dstDir):
            fullfn = os.path.join(dstDir, fn)
            if dstExcludeFullfnList is not None and fullfn in dstExcludeFullfnList:
                continue
            for fullfn2 in reversed(glob.glob(fullfn, recursive=True)):             # iterate from leaf node to root node
                if fullfn2 not in fullfnSet:
                    if os.path.islink(fullfn2):
                        os.unlink(fullfn2)
                    elif os.path.isdir(fullfn2):
                        os.rmdir(fullfn2)
                    else:
                        assert False


class HttpdirfsMount:

    def __init__(self, cacheDir, cfgFile, url, mntDir):
        self.cacheDir = cacheDir
        self.cfgFile = cfgFile
        self.url = url
        self.mntDir = mntDir

    def __enter__(self):
        # self.__exit__() won't be called on SIGTERM without this
        # self.__exit__() call is strictly required because httpdirfs won't unmount on SIGTERM, httpdirfs sucks
        assert signal.getsignal(signal.SIGTERM) == signal.getsignal(signal.SIGINT)

        os.makedirs(self.cacheDir, exist_ok=True)
        with open(self.cfgFile, "w") as f:
            f.write("")
        Util.forceMakeEmptyDir(self.mntDir)

        subprocess.check_call([
            "httpdirfs",
            "--config", self.cfgFile,
            "--cache",
            "--cache-location", self.cacheDir,                  # httpdirfs crashes if --cache-location is a relative path, httpdirfs sucks
            "-o", "ro,allow_root,allow_other,auto_unmount",
            self.url,
            self.mntDir,
        ])

    def __exit__(self, exc_type, exc_value, exc_tb):
        subprocess.check_call(["umount", self.mntDir])
        os.rmdir(self.mntDir)
        os.unlink(self.cfgFile)


###############################################################################


if __name__ == "__main__":
    signal.signal(signal.SIGTERM, signal.getsignal(signal.SIGINT))
    with mirrors.plugin.ApiClient() as sock:
        try:
            Main().run()
            sock.progress_changed(100)
        except Exception:
            sock.error_occured(sys.exc_info())
            raise
